</> 技術筆記Tech Notes

Windows Server 深度防禦架構:核心原理與自動化部署實務

在現代 IT 基礎架構中,Windows Server 經常作為企業網路的核心(例如 Active Directory、ERP 系統、MSSQL 資料庫)。將其暴露於網際網路或內部複雜的網路環境中,容易面臨 RDP 暴力破解、無檔案攻擊 (Fileless Malware) 與勒索軟體 (Ransomware) 等資安風險。

傳統的防毒軟體或邊界防火牆,較難防範具備「就地取材 (Living off the Land)」特性的進階持續性威脅 (APT)。本文將探討「深度防禦 (Defense in Depth)」在 Windows Server 上的具體實作,提供自動化的加固腳本,並說明如何利用 Windows 內建機制(WMI、LSA、SACL、EventLog)在維持系統效能的前提下建立多層次監控防線。

核心架構理念:事件驅動與效能最佳化

在 Linux 環境中,常使用迴圈搭配 ps 指令進行輪詢(Polling)監控。但在 Windows 環境中,高頻率的輪詢容易導致 CPU 資源的大量耗損。

因此,本架構的核心思維為「事件驅動 (Event-Driven)」結合「狀態追蹤 (Stateful Tracking)」。我們利用系統底層機制被動接收事件,減少主動輪詢:

  1. 浮動游標機制 (EventRecordID Tracking): 避免每次讀取完整的 Event Log。透過記錄 LastRecordId,腳本每次執行時只處理新的事件紀錄,有效降低 CPU 負載。

  2. 高頻事件批次處理 (Batching): 針對產生頻率較高的事件(如 Event ID 4688 處理程序建立),改用定期批次掃描的策略,在不遺漏事件的前提下維持伺服器運作穩定。

模組原理解析 (Under the Hood)

1. 憑證保護與雙軌通訊 (Secure Vault & Duality Log)

  • 面臨威脅: 寫死(Hardcode)在腳本中的 API Token 容易被低權限帳號或 WebShell 竊取;外部通訊若遭防火牆阻擋會導致警報遺失。

  • 底層實作:C:\SecureMonitor 建立憑證存放區,並利用 Windows 的 NTFS ACL (存取控制清單) 停用權限繼承(Disable Inheritance),僅允許 SYSTEMAdministrators 讀寫,防範未授權竄改。

  • 工程細節: 警報發送採用雙軌機制。安全事件會先透過 Write-EventLog 寫入本機的「應用程式」事件檢視器,確保日誌具備不可否認性(Non-repudiation)並利於事後鑑識;接著再透過 HTTPS 發送 Telegram 通知。腳本內建狀態計數器,相同觸發源發送達 30 次後會暫停發送 1 小時,避免警報疲勞 (Alert Fatigue) 與 API 限制。

2. 帳號與關鍵檔案監控 (SACL & Audit Policy)

  • 面臨威脅: 攻擊者搜尋 Web 根目錄(如 C:\inetpub\wwwroot)竊取機密設定檔(如 .json.env),或私自建立本機管理員帳號。

  • 底層實作: 相較於耗費資源且穩定性較低的 FileSystemWatcher,改用 Windows 核心的 進階稽核原則 (Advanced Audit Policy)

  • 工程細節: 為關鍵檔案與高風險目錄掛載 SACL (系統存取控制清單)。任何未經授權的程序嘗試存取受保護的設定檔時,Windows LSA 會在安全性日誌中觸發 Event ID 4663,藉此記錄探測與存取行為。

3. RDP 遠端登入非同步攔截 (Task Scheduler XPath)

  • 面臨威脅: 非授權的遠端桌面 (RDP) 存取或內部橫向移動。

  • 底層實作: Windows 登入驗證由 LSA 負責並產生 Event ID 4624。本架構利用 Windows 排程工作的 XML XPath 事件觸發器 功能。

  • 工程細節: 將排程工作綁定特定條件 *[System[EventID=4624]] and *[EventData[Data[@Name='LogonType']='10']]。當 RDP 登入成功時,作業系統會觸發 PowerShell 腳本發送警報,不影響使用者的正常登入流程。

4. Defender 威脅攔截 (AMSI & Event 1116)

  • 面臨威脅: 記憶體中的惡意 PowerShell 指令或無檔案攻擊 (Fileless Attack)。

  • 底層實作: 透過 Windows 內建的 AMSI (反惡意軟體掃描介面),Windows Defender 可掃描記憶體中動態解碼的腳本。監控 Defender 的作業日誌 (Event ID 1116),及時擷取並通報被防毒引擎攔截的威脅。

5. Fail2Ban 動態防火牆防禦

  • 面臨威脅: 針對 RDP (Port 3389) 或 SMB (Port 445) 的密碼猜測與暴力破解。

  • 底層實作: 定期讀取安全性日誌中的登入失敗紀錄 (Event ID 4625)。為了避免不同 Windows 版本的欄位差異,使用 XML 解析 (Get-EventData) 準確擷取 IpAddress

  • 工程細節: 當特定 IP 失敗次數超過設定的閾值,腳本會透過 New-NetFirewallRule 在 Windows Defender 防火牆新增 Inbound 封鎖規則。同時實作自動解封機制 (Cleanup-ExpiredBlocks),定期清除過期的防火牆規則,避免規則過度累積影響效能。

6. 處理程序監控與指令混淆偵測 (Process Watch & Regex)

  • 面臨威脅: 攻擊者利用 SQL Injection 現場編譯程式,或執行經 Base64 混淆的 PowerShell 指令(如 -EncodedCommand)。

  • 底層實作: 針對 Event ID 4688 實施批次處理,並使用正規表示式(Regex)進行特徵比對。

  • 工程細節: 攻擊者常使用參數縮寫規避檢查。透過優化後的正規表示式 (?i)-(e|en|enc|ec|encodedcommand)\s+[A-Za-z0-9+/]{20,},可有效辨識惡意混淆語法及非預期的提權工具,並觸發警報。

7. 系統服務狀態監控 (SCM Event Trigger)

  • 面臨威脅: 攻擊者取得權限後,關閉防毒軟體或監控服務。

  • 底層實作: 監聽 Windows 服務控制管理員 (SCM) 的 Event ID 7034 (服務意外終止) 與相關停止事件。當關鍵防護服務停止時,系統會立即發出通報。

實戰部署

本架構將上述防禦機制整合為具備「事件驅動、狀態追蹤、低資源消耗」的 PowerShell 自動化部署腳本。

💡 註: 實際部署時,腳本將自動建立包含浮動游標、XML 事件過濾器、自動解封清理機制的排程工作。請以系統管理員身分執行安裝腳本即可完成部署。

日常維運與事件回應 (Day 2 Operations & Incident Response)

系統上線後,採用「預設警報(Default Alert)」原則。常見的維運情境如下:

1. 授權合法的新服務(Process Whitelist Management)

  • 情境: 安裝新軟體(例如 Node.js 或內部開發的 API 程式)後,收到「可疑行程警報」。

  • 回應方式: 這是預期的防護行為。系統會過濾具備微軟數位簽章的檔案,若為自定義合法工具,請將程式名稱(不包含 .exe)加入 C:\SecureMonitor\Whitelist.txt。系統預設每 5 分鐘會自動重新讀取白名單,無需重新啟動服務。

2. 解除 IP 封鎖與狀態觀察(Fail2Ban Policy Management)

  • 情境: 使用者因密碼輸入錯誤次數過多被系統封鎖,無法透過 RDP 連線。

  • 回應方式:

    封鎖機制具備自動解封(Auto-Unban)能力,時間到期後會自動清除規則。若需手動提前解除封鎖,請以系統管理員身分開啟 PowerShell 執行:

    # 列出所有被封鎖的 IP 規則
    Get-NetFirewallRule -DisplayName "Block_Malicious_IP_*"
    
    # 移除特定 IP 的封鎖規則
    Remove-NetFirewallRule -DisplayName "Block_Malicious_IP_192.168.1.100"
    

3. 防禦機制驗證(Validation)

  • 情境: 部署完成後,建議主動進行測試,確認各項防禦與觸發器運作正常。

  • 驗收方式:

    請親自執行以下動作來觸發系統警報,確認是否能順利收到 Telegram 通知:

    1. 檔案竄改警報: 以系統管理員身分編輯受 SACL 保護的檔案(如 C:\Windows\System32\drivers\etc\hosts),新增一行空白並存檔。

    2. 混淆語法警報: 開啟命令提示字元 (cmd) 並輸入 powershell.exe -enc QQBiA...(後接任意超過 20 字元的 Base64 字串),驗證 Regex 攔截器。

    3. 暴力破解攔截: 從另一台電腦對伺服器進行 RDP 連線,並連續輸入錯誤密碼達設定的閾值,觀察防火牆是否產生對應的阻擋規則。

結語

透過整合 Windows 作業系統的內建機制(WMI、LSA、SACL、EventLog),並導入浮動游標、批次處理與自動解封等實務做法,能以較低的系統資源消耗,建立涵蓋面廣且具備阻斷能力的防禦架構,藉此提升 Windows Server 抵禦進階威脅的防護力。

附錄:腳本

#Requires -RunAsAdministrator
[CmdletBinding()]
param(
    [string]$BaseDir = "C:\SecureDBMonitor",

    # Telegram 可留空:留空則只寫入 EventLog + 本機檔案
    [string]$TG_TOKEN = "",
    [string]$TG_CHAT_ID = "",

    # SQL Server 連線與服務名稱(預設為本機預設實例)
    [string]$SqlInstance = "localhost",
    [string]$SqlServiceName = "MSSQLSERVER",
    [string]$SqlServiceAccount = "NT SERVICE\MSSQLSERVER",

    # 是否阻擋 SQL 服務對外連線(可能影響某些功能:Linked Server/外部存取等)
    [bool]$BlockSqlOutbound = $true,

    # 你說 DB 上的 C:\WWWROOT 是舊殘留且不執行網站:預設做 ACL 隔離 + SACL 稽核
    [bool]$QuarantineLegacyWwwroot = $true,

    # Fail2Ban
    [int]$Fail2BanThreshold = 8,
    [int]$Fail2BanWindowMinutes = 10,
    [int]$Fail2BanBlockHours = 24
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

function Ensure-Dir([string]$Path) {
    if (-not (Test-Path $Path)) { New-Item -ItemType Directory -Path $Path | Out-Null }
}

function Lock-DirAcl([string]$Path) {
    $acl = Get-Acl $Path
    $acl.SetAccessRuleProtection($true, $false) | Out-Null
    $acl.Access | ForEach-Object { $acl.RemoveAccessRule($_) | Out-Null } | Out-Null

    $systemRule = New-Object System.Security.AccessControl.FileSystemAccessRule(
        "NT AUTHORITY\SYSTEM","FullControl","ContainerInherit,ObjectInherit","None","Allow"
    )
    $adminRule  = New-Object System.Security.AccessControl.FileSystemAccessRule(
        "BUILTIN\Administrators","FullControl","ContainerInherit,ObjectInherit","None","Allow"
    )
    $acl.AddAccessRule($systemRule) | Out-Null
    $acl.AddAccessRule($adminRule) | Out-Null
    Set-Acl -Path $Path -AclObject $acl
}

function Add-Sacl([string]$Path) {
    if (-not (Test-Path $Path)) { return }
    $acl = Get-Acl $Path

    # 稽核 Everyone 的「寫入/刪除/權限變更」成功與失敗(避免讀取事件爆量)
    $rights = [System.Security.AccessControl.FileSystemRights]::WriteData `
            -bor [System.Security.AccessControl.FileSystemRights]::AppendData `
            -bor [System.Security.AccessControl.FileSystemRights]::Delete `
            -bor [System.Security.AccessControl.FileSystemRights]::ChangePermissions `
            -bor [System.Security.AccessControl.FileSystemRights]::TakeOwnership

    $inherit = [System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit"
    $prop   = [System.Security.AccessControl.PropagationFlags]::None
    $flags  = [System.Security.AccessControl.AuditFlags]"Success, Failure"

    $auditRule = New-Object System.Security.AccessControl.FileSystemAuditRule(
        "Everyone", $rights, $inherit, $prop, $flags
    )
    $acl.AddAuditRule($auditRule) | Out-Null
    Set-Acl -Path $Path -AclObject $acl
}

function Ensure-EventSource([string]$SourceName) {
    if (-not [System.Diagnostics.EventLog]::SourceExists($SourceName)) {
        New-EventLog -LogName Application -Source $SourceName
    }
}

# -----------------------------
# 目錄與檔案結構
# -----------------------------
Write-Host "Deploy SecureDBMonitor (DB-only) to $BaseDir ..." -ForegroundColor Cyan

Ensure-Dir $BaseDir
Ensure-Dir (Join-Path $BaseDir "State")
Ensure-Dir (Join-Path $BaseDir "Logs")
Ensure-Dir (Join-Path $BaseDir "PSLogs")
Lock-DirAcl $BaseDir

$EnvFile    = Join-Path $BaseDir "SecureEnv.ps1"
$CommonFile = Join-Path $BaseDir "SecureDB.Common.ps1"

$StateDir = Join-Path $BaseDir "State"
$LogDir   = Join-Path $BaseDir "Logs"

Ensure-EventSource "SecureDBMonitor"

@"
`$Global:SecureDB = @{
    BaseDir = '$BaseDir'
    StateDir = '$StateDir'
    LogDir = '$LogDir'
    TG_TOKEN = '$TG_TOKEN'
    TG_CHAT_ID = '$TG_CHAT_ID'
    Fail2BanThreshold = $Fail2BanThreshold
    Fail2BanWindowMinutes = $Fail2BanWindowMinutes
    Fail2BanBlockHours = $Fail2BanBlockHours
}
"@ | Set-Content -Path $EnvFile -Encoding UTF8

@'
. "$PSScriptRoot\SecureEnv.ps1"

function Write-LocalLog([string]$Line) {
    $file = Join-Path $Global:SecureDB.LogDir "SecureDB.log"
    $ts = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
    Add-Content -Path $file -Value "[$ts] $Line"
}

function Send-Alert([string]$Message, [int]$EventId = 2001, [string]$EntryType = "Warning") {
    try {
        Write-EventLog -LogName Application -Source "SecureDBMonitor" -EventId $EventId -EntryType $EntryType -Message $Message
    } catch {}
    Write-LocalLog $Message

    # Telegram optional
    if ([string]::IsNullOrWhiteSpace($Global:SecureDB.TG_TOKEN) -or [string]::IsNullOrWhiteSpace($Global:SecureDB.TG_CHAT_ID)) { return }

    # simple throttle: max 30/hour
    $counterFile = Join-Path $Global:SecureDB.StateDir "TgCounter.txt"
    $count = 0
    $since = Get-Date
    if (Test-Path $counterFile) {
        $c = Get-Content $counterFile -ErrorAction SilentlyContinue
        if ($c.Count -ge 2) {
            $count = [int]$c[0]
            $since = [datetime]$c[1]
            if ((Get-Date) -gt $since.AddHours(1)) { $count = 0; $since = Get-Date }
        }
    }
    if ($count -ge 30) { return }
    $count++
    Set-Content -Path $counterFile -Value ("$count`n" + $since.ToString("o"))

    $uri = "https://api.telegram.org/bot$($Global:SecureDB.TG_TOKEN)/sendMessage"
    $body = @{ chat_id = $Global:SecureDB.TG_CHAT_ID; text = $Message }
    try { Invoke-RestMethod -Uri $uri -Method Post -Body $body -TimeoutSec 6 -ErrorAction Stop | Out-Null } catch {}
}

function Get-EventData([System.Diagnostics.Eventing.Reader.EventRecord]$Event) {
    $xml = [xml]$Event.ToXml()
    $data = @{}
    foreach ($d in $xml.Event.EventData.Data) {
        if ($null -ne $d.Name -and $d.Name -ne "") {
            $data[$d.Name] = [string]$d.'#text'
        }
    }
    return $data
}

function Read-LastRecordId([string]$LogName) {
    $f = Join-Path $Global:SecureDB.StateDir "$LogName.last"
    if (-not (Test-Path $f)) { return 0 }
    try { return [int64](Get-Content $f -ErrorAction Stop) } catch { return 0 }
}

function Write-LastRecordId([string]$LogName, [int64]$RecordId) {
    $f = Join-Path $Global:SecureDB.StateDir "$LogName.last"
    Set-Content -Path $f -Value ([string]$RecordId)
}

function Escape-Xml([string]$s) {
    return $s.Replace("&","&amp;").Replace("<","&lt;").Replace(">","&gt;").Replace('"',"&quot;").Replace("'","&apos;")
}

function Build-QueryXml([string]$LogName, [int64]$LastRecordId, [int[]]$Ids) {
    $idExpr = ($Ids | ForEach-Object { "EventID=$_"} ) -join " or "
    @"
<QueryList>
  <Query Id="0" Path="$LogName">
    <Select Path="$LogName">*[System[($idExpr) and (EventRecordID &gt; $LastRecordId)]]</Select>
  </Query>
</QueryList>
"@
}

function Ensure-BlockRule([string]$Ip, [int]$Hours) {
    if ([string]::IsNullOrWhiteSpace($Ip) -or $Ip -eq "-" ) { return }
    $ruleName = "SecureDB_Block_$Ip"
    if (-not (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue)) {
        New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Action Block -RemoteAddress $Ip | Out-Null
        Send-Alert "[Fail2Ban] 已封鎖 $Ip($Hours 小時)" 2105 "Warning"
    }
    # store expiry
    $f = Join-Path $Global:SecureDB.StateDir "blocked.json"
    $blocked = @{}
    if (Test-Path $f) {
        try { $blocked = (Get-Content $f -Raw | ConvertFrom-Json) } catch { $blocked = @{} }
    }
    $blocked[$Ip] = (Get-Date).AddHours($Hours).ToString("o")
    ($blocked | ConvertTo-Json) | Set-Content -Path $f -Encoding UTF8
}

function Cleanup-ExpiredBlocks() {
    $f = Join-Path $Global:SecureDB.StateDir "blocked.json"
    if (-not (Test-Path $f)) { return }
    $blocked = @{}
    try { $blocked = (Get-Content $f -Raw | ConvertFrom-Json) } catch { return }

    $now = Get-Date
    $changed = $false
    foreach ($k in @($blocked.PSObject.Properties.Name)) {
        try {
            $exp = [datetime]$blocked.$k
            if ($now -ge $exp) {
                $ruleName = "SecureDB_Block_$k"
                Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue | Remove-NetFirewallRule -ErrorAction SilentlyContinue
                $blocked.PSObject.Properties.Remove($k)
                $changed = $true
                Send-Alert "[Fail2Ban] 已自動解封 $k" 2106 "Information"
            }
        } catch {}
    }
    if ($changed) { ($blocked | ConvertTo-Json) | Set-Content -Path $f -Encoding UTF8 }
}
'@ | Set-Content -Path $CommonFile -Encoding UTF8

# -----------------------------
# 子腳本:Security 事件處理器
# -----------------------------
$SecurityProcessor = Join-Path $BaseDir "SecurityEventProcessor.ps1"
@'
. "$PSScriptRoot\SecureDB.Common.ps1"

$logName = "Security"
$ids = @(4624,4625,4663,4698,4702,4719,4720,4728,4732,1102)

$last = Read-LastRecordId $logName
$q = Build-QueryXml -LogName $logName -LastRecordId $last -Ids $ids

$events = @()
try { $events = Get-WinEvent -FilterXml $q -ErrorAction Stop } catch { $events = @() }

if ($events.Count -eq 0) { Cleanup-ExpiredBlocks; exit 0 }

$maxRid = $last
foreach ($ev in ($events | Sort-Object RecordId)) {
    if ($ev.RecordId -gt $maxRid) { $maxRid = $ev.RecordId }
    $data = Get-EventData $ev
    $ts = $ev.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss")

    switch ($ev.Id) {
        1102 {
            $u = "$($data['SubjectDomainName'])\$($data['SubjectUserName'])"
            Send-Alert "[高危] Security Log 被清除 (1102)`n時間:$ts`n帳號:$u`n主機:$env:COMPUTERNAME" 2202 "Error"
        }
        4624 {
            if ($data["LogonType"] -eq "10") {
                $u = "$($data['TargetDomainName'])\$($data['TargetUserName'])"
                $ip = $data["IpAddress"]
                Send-Alert "[RDP 登入]`n時間:$ts`n帳號:$u`n來源IP:$ip`n主機:$env:COMPUTERNAME" 2204 "Warning"
            }
        }
        4625 {
            # 只做爆破統計與封鎖;避免每次失敗都狂噴告警
            # 仍會寫入 Application event + 本機檔案(Send-Alert 在封鎖時才呼叫)
        }
        4663 {
            $obj = $data["ObjectName"]
            if ($obj -and $obj.ToLower().StartsWith("c:\wwwroot")) {
                $u = "$($data['SubjectDomainName'])\$($data['SubjectUserName'])"
                Send-Alert "[關鍵目錄異動] (4663)`n時間:$ts`n路徑:$obj`n帳號:$u`n主機:$env:COMPUTERNAME" 2210 "Warning"
            }
        }
        4698 {
            $u = "$($data['SubjectDomainName'])\$($data['SubjectUserName'])"
            $task = $data["TaskName"]
            Send-Alert "[排程任務建立] (4698)`n時間:$ts`n任務:$task`n帳號:$u`n主機:$env:COMPUTERNAME" 2212 "Warning"
        }
        4702 {
            $u = "$($data['SubjectDomainName'])\$($data['SubjectUserName'])"
            $task = $data["TaskName"]
            Send-Alert "[排程任務修改] (4702)`n時間:$ts`n任務:$task`n帳號:$u`n主機:$env:COMPUTERNAME" 2213 "Warning"
        }
        4719 {
            $u = "$($data['SubjectDomainName'])\$($data['SubjectUserName'])"
            Send-Alert "[稽核策略變更] (4719)`n時間:$ts`n帳號:$u`n主機:$env:COMPUTERNAME" 2214 "Warning"
        }
        4720 {
            $u = "$($data['SubjectDomainName'])\$($data['SubjectUserName'])"
            $new = $data["TargetUserName"]
            Send-Alert "[本機帳號建立] (4720)`n時間:$ts`n新帳號:$new`n建立者:$u`n主機:$env:COMPUTERNAME" 2216 "Warning"
        }
        4728 { Send-Alert "[高權群組變更] (4728)`n時間:$ts`n主機:$env:COMPUTERNAME" 2217 "Warning" }
        4732 { Send-Alert "[高權群組變更] (4732)`n時間:$ts`n主機:$env:COMPUTERNAME" 2218 "Warning" }
    }
}

# 爆破統計 → 封鎖(只在有 4625 事件時跑,且會自動解封)
Cleanup-ExpiredBlocks
$winMin = [int]$Global:SecureDB.Fail2BanWindowMinutes
$threshold = [int]$Global:SecureDB.Fail2BanThreshold
$blockHours = [int]$Global:SecureDB.Fail2BanBlockHours

$start = (Get-Date).AddMinutes(-$winMin)
try {
    $fails = Get-WinEvent -FilterHashtable @{LogName="Security"; Id=4625; StartTime=$start} -ErrorAction Stop
} catch { $fails = @() }

if ($fails.Count -gt 0) {
    # 🌟 修改點 1:改用 Get-EventData 解析 XML,捨棄 Properties[19] 以確保相容性
    $ips = foreach ($f in $fails) {
        $d = Get-EventData $f
        $ip = $d["IpAddress"]
        if ($ip -and $ip -ne "-" -and $ip -ne "127.0.0.1" -and $ip -ne "::1") { $ip }
    }
    $grp = $ips | Group-Object | Where-Object { $_.Count -ge $threshold }
    foreach ($g in $grp) { Ensure-BlockRule -Ip $g.Name -Hours $blockHours }
}

Write-LastRecordId $logName $maxRid
'@ | Set-Content -Path $SecurityProcessor -Encoding UTF8

# -----------------------------
# 子腳本:System 事件處理器
# -----------------------------
$SystemProcessor = Join-Path $BaseDir "SystemEventProcessor.ps1"
@'
. "$PSScriptRoot\SecureDB.Common.ps1"

$logName = "System"
$ids = @(7045)

$last = Read-LastRecordId $logName
$q = Build-QueryXml -LogName $logName -LastRecordId $last -Ids $ids

$events = @()
try { $events = Get-WinEvent -FilterXml $q -ErrorAction Stop } catch { $events = @() }
if ($events.Count -eq 0) { exit 0 }

$maxRid = $last
foreach ($ev in ($events | Sort-Object RecordId)) {
    if ($ev.RecordId -gt $maxRid) { $maxRid = $ev.RecordId }
    $data = Get-EventData $ev
    $ts = $ev.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss")

    if ($ev.Id -eq 7045) {
        $svc = $data["ServiceName"]
        $img = $data["ImagePath"]
        $acc = $data["AccountName"]
        Send-Alert "[服務建立] (7045)`n時間:$ts`n服務:$svc`n路徑:$img`n帳號:$acc`n主機:$env:COMPUTERNAME" 2301 "Warning"
    }
}

Write-LastRecordId $logName $maxRid
'@ | Set-Content -Path $SystemProcessor -Encoding UTF8

# -----------------------------
# 子腳本:可疑 4688 命令列監控(低頻)
# -----------------------------
$ProcWatch = Join-Path $BaseDir "ProcessWatch.ps1"
@'
. "$PSScriptRoot\SecureDB.Common.ps1"

$logName = "Security.4688"
$last = Read-LastRecordId $logName

# 只掃 4688;每 2 分鐘一次,避免每筆都觸發造成壓力
$ids = @(4688)
$q = Build-QueryXml -LogName "Security" -LastRecordId $last -Ids $ids

$events = @()
try { $events = Get-WinEvent -FilterXml $q -ErrorAction Stop } catch { $events = @() }
if ($events.Count -eq 0) { exit 0 }

$suspBins = @(
  "powershell.exe","pwsh.exe","cmd.exe","wscript.exe","cscript.exe","mshta.exe",
  "rundll32.exe","regsvr32.exe","certutil.exe","bitsadmin.exe","curl.exe","wget.exe",
  "schtasks.exe","net.exe","sc.exe","wmic.exe"
)

$suspRegex = @(
  # 🌟 修改點 2:改良 PowerShell EncodedCommand 的正則,防誤判且能抓到 -ec, -en, -enc
  "(?i)-(e|en|enc|ec|encodedcommand)\s+[A-Za-z0-9+/]{20,}", 
  "(?i)downloadstring\(",
  "(?i)invoke-webrequest|iwr\s",
  "(?i)invoke-expression|iex\s",
  "(?i)schtasks\s+/create",
  "(?i)net\s+user\s+.+\s+/add",
  "(?i)regsvr32\s+.+/i:",
  "(?i)rundll32\s+.+,",
  "(?i)certutil\s+-urlcache",
  "(?i)bitsadmin\s+/transfer"
)

$maxRid = $last
foreach ($ev in ($events | Sort-Object RecordId)) {
    if ($ev.RecordId -gt $maxRid) { $maxRid = $ev.RecordId }
    $d = Get-EventData $ev
    $ts = $ev.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss")

    $proc = [IO.Path]::GetFileName(($d["NewProcessName"] ?? ""))
    $cmd  = ($d["CommandLine"] ?? "")
    $parent = $d["ParentProcessName"]
    $user = "$($d['SubjectDomainName'])\$($d['SubjectUserName'])"

    $hit = $false
    if ($proc -and ($suspBins -contains $proc.ToLower())) { $hit = $true }
    if (-not $hit -and $cmd) {
        foreach ($r in $suspRegex) { if ($cmd -match $r) { $hit = $true; break } }
    }

    if ($hit) {
        Send-Alert "[可疑程序/命令列] (4688)`n時間:$ts`n使用者:$user`n程序:$($d['NewProcessName'])`n父程序:$parent`n命令列:$cmd`n主機:$env:COMPUTERNAME" 2401 "Warning"
    }
}

Write-LastRecordId $logName $maxRid
'@ | Set-Content -Path $ProcWatch -Encoding UTF8

# -----------------------------
# 系統加固:稽核/PowerShell/LSA/SMB
# -----------------------------
Write-Host "[1/6] 啟用稽核與 4688 命令列..." -ForegroundColor Cyan
auditpol /set /subcategory:"Logon" /success:enable /failure:enable | Out-Null
auditpol /set /subcategory:"Process Creation" /success:enable /failure:enable | Out-Null
auditpol /set /subcategory:"User Account Management" /success:enable /failure:enable | Out-Null
auditpol /set /subcategory:"Security Group Management" /success:enable /failure:enable | Out-Null
auditpol /set /subcategory:"Scheduled Task" /success:enable /failure:enable | Out-Null
auditpol /set /subcategory:"File System" /success:enable /failure:enable | Out-Null

# 4688 include command line
$cmdLineKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit"
if (-not (Test-Path $cmdLineKey)) { New-Item -Path $cmdLineKey -Force | Out-Null }
Set-ItemProperty -Path $cmdLineKey -Name "ProcessCreationIncludeCmdLine_Enabled" -Value 1 -Type DWord -Force

Write-Host "[2/6] 啟用 PowerShell Logging..." -ForegroundColor Cyan
$sb = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging"
if (-not (Test-Path $sb)) { New-Item -Path $sb -Force | Out-Null }
Set-ItemProperty -Path $sb -Name "EnableScriptBlockLogging" -Value 1 -Type DWord -Force

$ml = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging"
if (-not (Test-Path $ml)) { New-Item -Path $ml -Force | Out-Null }
Set-ItemProperty -Path $ml -Name "EnableModuleLogging" -Value 1 -Type DWord -Force
$mlNames = Join-Path $ml "ModuleNames"
if (-not (Test-Path $mlNames)) { New-Item -Path $mlNames -Force | Out-Null }
New-ItemProperty -Path $mlNames -Name "*" -Value "*" -PropertyType String -Force | Out-Null

$tr = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription"
if (-not (Test-Path $tr)) { New-Item -Path $tr -Force | Out-Null }
Set-ItemProperty -Path $tr -Name "EnableTranscripting" -Value 1 -Type DWord -Force
Set-ItemProperty -Path $tr -Name "OutputDirectory" -Value (Join-Path $BaseDir "PSLogs") -Type String -Force

Write-Host "[3/6] 設定 LSA/NTLMv2/停用 SMBv1..." -ForegroundColor Cyan
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa" -Name "RunAsPPL" -Value 1 -Type DWord -Force
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa" -Name "LmCompatibilityLevel" -Value 5 -Type DWord -Force

# WDigest 防明文(多數新版預設就是 0,這裡明確寫死)
$wd = "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\WDigest"
if (-not (Test-Path $wd)) { New-Item -Path $wd -Force | Out-Null }
Set-ItemProperty -Path $wd -Name "UseLogonCredential" -Value 0 -Type DWord -Force

Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force -Confirm:$false | Out-Null

# -----------------------------
# 隔離 + 稽核 C:\WWWROOT(你說是舊殘留)
# -----------------------------
Write-Host "[4/6] 處理 C:\WWWROOT(隔離 ACL + SACL 稽核)..." -ForegroundColor Cyan
if (Test-Path "C:\WWWROOT") {
    if ($QuarantineLegacyWwwroot) {
        Lock-DirAcl "C:\WWWROOT"
    }
    Add-Sacl "C:\WWWROOT"
}

# -----------------------------
# SQL Server 加固(關高危功能)
# -----------------------------
Write-Host "[5/6] SQL Server 加固(關 xp_cmdshell/CLR/OLE/AdHoc)..." -ForegroundColor Cyan
$sqlQuery = @"
EXEC sp_configure 'show advanced options', 1; RECONFIGURE;
EXEC sp_configure 'xp_cmdshell', 0; RECONFIGURE;
EXEC sp_configure 'clr enabled', 0; RECONFIGURE;
EXEC sp_configure 'Ole Automation Procedures', 0; RECONFIGURE;
EXEC sp_configure 'Ad Hoc Distributed Queries', 0; RECONFIGURE;
"@
try {
    sqlcmd -S $SqlInstance -E -Q $sqlQuery -t 10 | Out-Null
    Write-EventLog -LogName Application -Source "SecureDBMonitor" -EventId 2501 -EntryType Information -Message "SQL hardening applied via sqlcmd on $SqlInstance"
} catch {
    Write-EventLog -LogName Application -Source "SecureDBMonitor" -EventId 2502 -EntryType Warning -Message "SQL hardening FAILED via sqlcmd on $SqlInstance. Please verify connectivity/permissions."
}

# 可選:阻擋 SQL 服務對外連線
if ($BlockSqlOutbound) {
    Write-Host "       - 建立 MSSQL 出站阻擋規則(可能影響特定功能)" -ForegroundColor DarkGray
    Remove-NetFirewallRule -DisplayName "SecureDB_Block_${SqlServiceName}_Outbound" -ErrorAction SilentlyContinue | Out-Null
    New-NetFirewallRule -DisplayName "SecureDB_Block_${SqlServiceName}_Outbound" -Direction Outbound -Action Block -Service $SqlServiceName -Profile Any | Out-Null
}

# -----------------------------
# 建立排程:事件觸發 + 低頻 4688 掃描
# -----------------------------
Write-Host "[6/6] 建立排程任務(事件驅動 + 4688 低頻掃描)..." -ForegroundColor Cyan

function Register-EventTriggeredTask([string]$Name, [string]$ScriptPath, [string]$LogName, [int[]]$EventIds) {
    $action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$ScriptPath`""
    Register-ScheduledTask -TaskName $Name -Action $action -Trigger (New-ScheduledTaskTrigger -AtStartup) -User "SYSTEM" -Force | Out-Null

    $idExpr = ($EventIds | ForEach-Object { "EventID=$_"} ) -join " or "
    $sub = @"
<QueryList>
  <Query Id="0" Path="$LogName">
    <Select Path="$LogName">*[System[($idExpr)]]</Select>
  </Query>
</QueryList>
"@
    $escaped = $sub.Replace("&","&amp;").Replace("<","&lt;").Replace(">","&gt;").Replace('"',"&quot;").Replace("'","&apos;")

    $xml = Export-ScheduledTask -TaskName $Name
    $xml2 = $xml -replace '<Triggers>.*?</Triggers>', "<Triggers><EventTrigger><Enabled>true</Enabled><Subscription>$escaped</Subscription></EventTrigger></Triggers>"
    Set-ScheduledTask -TaskName $Name -Xml $xml2 | Out-Null
    Start-ScheduledTask -TaskName $Name | Out-Null
}

Register-EventTriggeredTask -Name "SecureDB_SecurityEvents" -ScriptPath $SecurityProcessor -LogName "Security" -EventIds @(4624,4625,4663,4698,4702,4719,4720,4728,4732,1102)
Register-EventTriggeredTask -Name "SecureDB_SystemEvents"   -ScriptPath $SystemProcessor   -LogName "System"   -EventIds @(7045)

# 4688 watch:每 2 分鐘掃一次(避免每筆 4688 觸發)
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) -RepetitionInterval (New-TimeSpan -Minutes 2)
$action  = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$ProcWatch`""
Register-ScheduledTask -TaskName "SecureDB_ProcessWatch" -Action $action -Trigger $trigger -User "SYSTEM" -Force | Out-Null
Start-ScheduledTask -TaskName "SecureDB_ProcessWatch" | Out-Null

# 完成通知
$done = @"
[SecureDBMonitor 上線]
主機:$env:COMPUTERNAME
SQL:$SqlInstance
重點:
- 4688 含命令列 + PS logging
- 事件驅動告警(RDP/爆破/任務/服務/清Log/WWWROOT異動)
- (可選) 阻擋 SQL 服務出站
"@
try { & (Join-Path $BaseDir "SecureDB.Common.ps1"); Send-Alert $done 2000 "Information" } catch {}
Write-Host "`nOK:DB 專用版部署完成。建議重開機一次讓部分 LSA 設定完整生效。" -ForegroundColor Green